Taeseong Blog

우아한 컴포넌트 만들기: React 컴파운드 패턴

2025-06-06

React

들어가며

개발을 하다 보면 하나의 UI를 구현하기 위해 여러 개의 컴포넌트가 복잡하게 얽히는 경우가 많습니다. 이처럼 UI 구성 요소들이 서로 얽히기 시작하면, 이 구조를 얼마나 깔끔하게 분리하고 유지보수하기 좋게 만들 수 있을지는 늘 고민스럽습니다.

예를 들어, 탭(Tab)과 탭 리스트(Tab List)를 가진 UI를 구현한다고 가정해볼게요.

import { useState } from 'react';

export function BadExample() {
  const [tab, setTab] = useState('account');

  return (
    <div className="tabs">
      <div className="tab-buttons">
        <div
          className={`tab-button ${tab === 'account' ? 'active' : ''}`}
          onClick={() => setTab('account')}>
          Account
        </div>
        <div
          className={`tab-button ${tab === 'password' ? 'active' : ''}`}
          onClick={() => setTab('password')}>
          Password
        </div>
      </div>
      <div className="tab-contents">
        {tab === 'account' && <div className="tab-content">Account content</div>}
        {tab === 'password' && <div className="tab-content">Password content</div>}
      </div>
    </div>
  );
}

처음 이 코드를 보는 개발자라면 각 요소가 어떤 역할을 하는지 파악하는 데 시간이 걸릴 수 있습니다. DOM 구조나 클래스 이름만 봐서는 이 컴포넌트가 어떤 식으로 동작하는지 명확하게 이해하기 어렵기 때문이죠.

반면에 널리 사용되는 UI 라이브러리인 shadcn/ui를 사용하다 보면 다음과 같은 구조의 컴포넌트를 자주 보게 됩니다.

import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';

export function Example() {
  return (
    <Tabs defaultValue="account">
      <TabsList>
        <TabsTrigger value="account">Account</TabsTrigger>
        <TabsTrigger value="password">Password</TabsTrigger>
      </TabsList>
      <TabsContent value="account">Account content</TabsContent>
      <TabsContent value="password">Password content</TabsContent>
    </Tabs>
  );
}

이 코드는 훨씬 구조화되어 있고, 각 컴포넌트의 역할도 명확하게 드러납니다. 어떤 요소가 탭 트리거인지, 어떤 요소가 콘텐츠를 나타내는지 이름만 봐도 바로 이해할 수 있죠.

이처럼 여러 컴포넌트가 상위 컴포넌트의 상태나 컨텍스트를 공유하면서 긴밀하게 협력해 동작하는 구조를 **컴파운드 컴포넌트 패턴(Compound Component Pattern)**이라고 부릅니다.

이번 글에서는 이 패턴을 활용해 아코디언(Accordion) 컴포넌트를 직접 구현해보겠습니다.

컴파운드 컴포넌트 패턴이란?

웹 개발에서는 서로 상태를 공유하며 동작해야 하는 컴포넌트들이 많습니다. 예를 들어, , 아코디언, 드롭다운 등에서는 각 구성 요소들이 서로를 인식하고 함께 작동해야 하죠.

컴파운드 컴포넌트 패턴은 이런 복잡한 상호작용을 우아하게 해결할 수 있는 React 디자인 패턴입니다.

HTML의 <select><option>처럼 자연스럽고 선언적인 API를 만들 수 있다는 것이 큰 장점입니다.

실전 예제: FAQ 아코디언 만들기

FAQ 아코디언 예시


1단계: Context API로 상태 관리하기

아코디언 컴포넌트는 여러 개의 아이템으로 구성되며, 이들끼리 하나의 상태(activeItem)를 공유해야 합니다. 사용자가 특정 아이템을 클릭했을 때 해당 항목을 열거나 닫는 동작을 처리하려면, 상태와 토글 함수를 공통으로 관리할 필요가 있죠.

이때 React의 Context API를 활용하면 하위 컴포넌트들에게 필요한 상태를 쉽게 전달할 수 있습니다.

import React, { createContext, useContext, useState } from 'react';

const AccordionContext = createContext();

function Accordion({ children, defaultValue = null }) {
  const [activeItem, setActiveItem] = useState(defaultValue);

  const toggleItem = (value) => {
    setActiveItem(activeItem === value ? null : value);
  };

  const contextValue = { activeItem, toggleItem };

  return (
    <AccordionContext.Provider value={contextValue}>
      <div className="accordion">{children}</div>
    </AccordionContext.Provider>
  );
}

위 코드에서는 AccordionContext를 통해 현재 열려 있는 항목(activeItem)과 항목을 토글하는 함수(toggleItem)를 하위 컴포넌트에 전달합니다. 이제 하위 컴포넌트들은 별도로 상태를 관리하지 않고도, Context에서 값을 가져와서 필요한 동작을 수행할 수 있게 됩니다.


2단계: 아코디언 아이템 구성하기

이제 실제 아코디언의 구조를 만들어봅니다. 아코디언은 각 항목마다 질문과 답변이 쌍으로 구성됩니다. 이를 위해 AccordionItem, AccordionTrigger, AccordionContent라는 세 가지 컴포넌트를 만들었습니다.

AccordionItem

AccordionItem은 하나의 아코디언 항목을 감싸는 래퍼입니다. value 속성을 통해 각각의 항목이 고유하게 식별됩니다.

function AccordionItem({ children, value }) {
  return (
    <div className="accordion-item" data-value={value}>
      {children}
    </div>
  );
}

AccordionTrigger

AccordionTrigger는 클릭 가능한 버튼 역할을 합니다. 이 버튼을 클릭하면 해당 항목이 열리거나 닫히고, 어떤 항목이 활성 상태인지에 따라 스타일이 달라집니다.

function AccordionTrigger({ children, value }) {
  const { activeItem, toggleItem } = useContext(AccordionContext);
  const isActive = activeItem === value;

  return (
    <button
      className={`accordion-trigger ${isActive ? 'active' : ''}`}
      onClick={() => toggleItem(value)}
      aria-expanded={isActive}>
      <span className="accordion-title">{children}</span>
      <span className={`accordion-icon ${isActive ? 'rotated' : ''}`}></span>
    </button>
  );
}
  • isActive가 true이면 해당 항목이 현재 열려 있는 상태입니다.

  • aria-expanded 속성은 접근성을 고려한 부분으로, 현재 열림 여부를 스크린 리더 등에 알려줍니다.

  • 아이콘 회전 등도 isActive에 따라 제어할 수 있습니다.

AccordionContent

AccordionContent는 실제로 열릴 콘텐츠 영역입니다. 현재 활성화된 항목(activeItem)과 value를 비교하여 보여줄지 여부를 판단합니다.

function AccordionContent({ children, value }) {
  const { activeItem } = useContext(AccordionContext);
  const isActive = activeItem === value;

  return (
    <div className={`accordion-content ${isActive ? 'expanded' : 'collapsed'}`}>
      <div className="accordion-content-inner">{children}</div>
    </div>
  );
}
  • 콘텐츠가 열릴 때와 닫힐 때 다른 클래스를 적용해 애니메이션 등을 줄 수 있습니다.

  • 내부에 별도의 wrapper(accordion-content-inner)를 둠으로써 레이아웃을 안정적으로 구성할 수 있습니다.

마지막으로, 위에서 만든 세 컴포넌트를 Accordion 컴포넌트에 속성처럼 연결해두면 다음과 같이 사용할 수 있게 됩니다:

Accordion.Item = AccordionItem;
Accordion.Trigger = AccordionTrigger;
Accordion.Content = AccordionContent;

이렇게 하면 <Accordion.Item>, <Accordion.Trigger>, <Accordion.Content>와 같은 방식으로 깔끔하게 사용할 수 있습니다. 구조를 명확하게 드러내고, 유지보수나 재사용성도 좋아지죠.

3단계: 완성된 아코디언 사용 예시

이제 앞서 만든 Accordion 컴포넌트를 실제로 사용하는 예제를 살펴보겠습니다. FAQ 페이지처럼 여러 질문과 답변을 나열할 때 유용하게 쓸 수 있습니다.

각 질문은 Accordion.Item으로 감싸고, 내부에는 Accordion.Trigger와 Accordion.Content로 구성합니다.

export default function FAQPage() {
  return (
    <div className="faq-container">
      <h1>자주 묻는 질문</h1>

      <Accordion defaultValue="shipping">
        <Accordion.Item value="shipping">
          <Accordion.Trigger value="shipping">🚚 배송은 얼마나 걸리나요?</Accordion.Trigger>
          <Accordion.Content value="shipping">
            일반 배송은 2-3, 익일배송은 다음날 오후 6시까지 도착합니다. 제주도 및 도서산간 지역은
            1-2일 추가 소요될 수 있습니다.
          </Accordion.Content>
        </Accordion.Item>

        <Accordion.Item value="return">
          <Accordion.Trigger value="return">🔄 교환/환불은 어떻게 하나요?</Accordion.Trigger>
          <Accordion.Content value="return">
            상품 수령 후 7일 이내 마이페이지에서 교환/환불 신청이 가능합니다. 단순 변심의 경우
            배송비는 고객 부담입니다.
          </Accordion.Content>
        </Accordion.Item>

        <Accordion.Item value="size">
          <Accordion.Trigger value="size">📏 사이즈가 맞지 않으면 어떻게 하나요?</Accordion.Trigger>
          <Accordion.Content value="size">
            사이즈 불만족 시 무료 교환 서비스를 제공합니다. 상품 페이지의 사이즈 가이드를 꼭
            확인해주세요.
          </Accordion.Content>
        </Accordion.Item>
      </Accordion>
    </div>
  );
}

defaultValue를 설정하면 페이지 진입 시 기본으로 열릴 항목을 지정할 수 있습니다.

각 항목은 value 속성으로 고유하게 구분되며, 토글 시 이 값을 기준으로 상태가 변경됩니다.

React.Children을 활용한 대안

Context API 없이도 비슷한 구조를 만들 수 있습니다. React.cloneElement를 사용하면 부모 컴포넌트가 자식에게 직접 prop을 주입할 수 있기 때문이죠.

이 방식은 간단한 구조에서는 유용하지만, 약간의 제약이 있습니다

Accordion 컴포넌트

function Accordion({ children, defaultValue = null }) {
  const [activeItem, setActiveItem] = useState(defaultValue);

  const toggleItem = (value) => {
    setActiveItem(activeItem === value ? null : value);
  };

  return (
    <div className="accordion">
      {React.Children.map(children, (child) =>
        React.cloneElement(child, { activeItem, toggleItem })
      )}
    </div>
  );
}

AccordionItem 컴포넌트

AccordionItem은 자식 컴포넌트에 필요한 prop을 전달하며 계층을 유지합니다.

function AccordionItem({ children, value, activeItem, toggleItem }) {
  return (
    <div className="accordion-item">
      {React.Children.map(children, (child) =>
        React.cloneElement(child, {
          value,
          activeItem,
          toggleItem,
          isActive: activeItem === value,
        })
      )}
    </div>
  );
}

1. React.Children 사용 시 구조 제한

  1. 구조 제한 React.Children 방식은 JSX 구조가 변경되면 동작하지 않을 수 있습니다.
// ❌ 작동하지 않음
<Accordion>
  <div>
    <Accordion.Item value="test">
      <Accordion.Trigger value="test">질문</Accordion.Trigger>
    </Accordion.Item>
  </div>
</Accordion>

// ✅ 작동함
<Accordion>
  <Accordion.Item value="test">
    <Accordion.Trigger value="test">질문</Accordion.Trigger>
  </Accordion.Item>
</Accordion>

중간에 <div>처럼 React가 예상하지 못하는 요소가 끼어들면 prop 주입이 제대로 되지 않습니다.

2. Props 충돌 주의

cloneElement로 주입한 props가 자식 컴포넌트의 기존 props와 충돌할 수 있습니다. 예를 들어 value, onClick 등 중복되는 prop은 의도치 않은 동작을 유발할 수 있습니다.

3. TypeScript 통합 시 복잡성

컴파운드 컴포넌트 패턴은 구조적으로 유연하지만, TypeScript와 함께 사용할 경우 타입 추론이 복잡해질 수 있습니다. 이럴 때는 명시적인 제네릭 타입이나 prop 타입 선언을 별도로 해주는 것이 좋습니다.

컴파운드 패턴은 타입 추론이 어려워지기 때문에 별도의 타입 관리 전략이 필요합니다.

  • 디버깅을 쉽게 하기 위해 displayName 설정:
AccordionItem.displayName = 'Accordion.Item';
AccordionTrigger.displayName = 'Accordion.Trigger';
AccordionContent.displayName = 'Accordion.Content';
  • PropTypes 또는 TypeScript로 prop 검증 추가:
Accordion.propTypes = {
  defaultValue: PropTypes.string,
  children: PropTypes.node.isRequired,
};

마무리

이번 글에서는 컴파운드 컴포넌트 패턴을 활용해 Context 기반의 Accordion UI를 구현하고, 이를 실제 FAQ 페이지에서 어떻게 사용할 수 있는지 살펴보았습니다.

이 패턴은 UI 라이브러리를 만들거나 복잡한 인터페이스를 구성할 때 매우 유용하게 쓰입니다. 단순히 라이브러리만 사용하는 것에서 나아가, 직접 구조를 설계해보는 경험은 컴포넌트 설계 역량을 키우는 데 큰 도움이 됩니다.

직접 코드를 구현해보고, 자신만의 방식으로 커스터마이징해보세요!